Taeseong Blog

동시 API 요청에서 토큰 리프레시 레이스 컨디션을 막는 방법

2026-02-09

TokenRefreshRace ConditionSingle-Flight 패턴

작성 중인 글입니다.

토큰 기반 인증을 사용하다 보면, 평소에는 잘 동작하다가 특정 순간에만 인증 에러가 연달아 터지는 경험을 하게 된다.

우리 서비스에서도 비슷한 문제가 있었다. 페이지 진입 시 여러 API가 동시에 호출되거나, 백그라운드 요청이 겹치는 타이밍에 갑자기 401 에러가 연속으로 발생했다.

처음에는 "재시도를 한 번 더 붙이면 되지 않을까?"라고 생각했지만, 문제는 단순한 재시도가 아니라 동시성 자체에 있었다.

이 글에서는 우리가 실제로 겪은 문제 상황과, 이를 Single-Flight 패턴으로 어떻게 해결했는지 정리해본다.

문제 상황: 동시에 발생하는 refresh 요청

문제는 아래와 같은 흐름에서 발생했다.

  1. 페이지 진입 시 여러 API 요청이 거의 동시에 발생
  2. access token이 만료된 상태라 모든 요청이 401 응답을 받음
  3. 각 요청의 에러 처리 로직에서 각자 refresh API를 호출

이때 실제로 벌어진 일은 다음과 같다.

  • refresh API가 여러 번 동시에 호출
  • access token이 아주 짧은 시간 안에 여러 번 갱신
  • 먼저 재시도한 요청이 사용한 토큰이 뒤늦게 도착한 refresh로 인해 이미 구 토큰이 되어버림
  • 결과적으로 일부 요청은 성공하고, 일부 요청은 다시 401 실패

즉, 분명 토큰은 갱신됐는데, 다시 인증이 되지 않는 상황이 만들어졌다.

재시도를 한 번 더 붙여도 해결되지 않았다. 동시에 여러 요청이 각자 갱신을 시도하는 구조 자체가 문제였기 때문이다.

문제의 본질: refresh 토큰까지 함께 갱신되는 구조

이 문제를 더 깊이 파고들어 보니, 원인은 다음과 같았다.

  • refresh API는 access token뿐 아니라 refresh token도 함께 재발급
  • 서버는 refresh 요청 시점의 refresh token을 기준으로 검증
  • 먼저 도착한 refresh 요청이 서버의 refresh token을 새 값으로 교체
  • 뒤늦게 도착한 refresh 요청은 이미 폐기된 refresh token으로 검증을 시도
  • 결과적으로 뒤 요청은 401 실패

즉, "refresh token 조회와 갱신이 동시에 일어나면서 발생한 레이스 컨디션"이었다.

해결 전략: refresh 흐름을 하나로 묶기 (Single-Flight)

이 문제를 해결하기 위해 우리가 세운 핵심 원칙은 하나였다.

"refresh는 반드시 한 번만 실행되고, 나머지 요청은 그 결과를 기다리게 하자."

적용 파일은 다음과 같다.

/src/shared/utils/fetch-wrapper.ts

1️⃣ refresh 요청을 Single-Flight로 묶기

먼저, 전역(모듈 스코프)에 refresh 상태를 공유할 Promise를 둔다.

let refreshTokenPromise: Promise<void> | null = null;

동작 원리는 간단하다.

  • refresh가 진행 중이면 새로 refresh를 호출하지 않는다
  • 대신 기존 refreshTokenPromise를 그대로 await
  • refresh가 끝나면 finally에서 다시 null로 초기화
const ensureRefreshed = async () => {
  if (!refreshTokenPromise) {
    refreshTokenPromise = fetchRefresh().finally(() => {
      refreshTokenPromise = null;
    });
  }

  return refreshTokenPromise;
};

이렇게 하면 동시에 여러 요청이 401을 받아도 실제 refresh API 호출은 단 한 번만 발생하고, 나머지 요청들은 "리프레시가 끝날 때까지 기다리는 역할"만 수행한다.

2️⃣ 토큰 저장 책임을 한 곳으로 모으기

또 하나 중요했던 점은 토큰을 누가, 언제 저장하느냐였다.

우리는 다음 규칙을 지켰다.

  • access token / refresh token 저장은 refresh 성공 시점에서만
  • 개별 API 요청에서는 토큰을 직접 갱신하지 않음
  • API 요청은 오직 "재시도"만 담당

이렇게 하니, 토큰 상태가 중간에 꼬일 일이 사라졌고 "누가 언제 토큰을 덮어썼는지" 추적할 필요도 없어졌다.

3️⃣ 재시도는 반드시 최신 토큰으로만

refresh가 끝난 뒤에 재시도할 때는 반드시 갱신이 완료된 토큰으로만 요청이 나가야 한다.

await ensureRefreshed();
return await sendRequest<T>(url, options);

대기 중이던 모든 요청이

  • 동일한 최신 access token을 사용해 재시도
  • "누군가는 새 토큰, 누군가는 옛 토큰" 같은 상태를 원천 차단

여기서 중요한 포인트는 반드시 await 해야 한다는 것이다. await이 없으면, 갱신이 끝나기도 전에 다시 요청이 나가며 같은 문제가 반복된다.

4️⃣ refresh 실패 처리도 Single-Flight로

refresh가 실패하는 경우도 고려해야 했다.

  • 여러 요청에서 동시에 refresh 실패
  • 로그아웃 API 중복 호출
  • 리다이렉트가 여러 번 발생

이 역시 같은 방식으로 묶었다.

let authFailurePromise: Promise<void> | null = null;

실패 시 처리 흐름은 다음과 같다.

  • 토큰 제거
  • /api/logout 호출
  • /login 페이지로 이동

이 과정을 단 한 번만 실행하도록 보장했다.

🔁 개선된 요청 흐름 요약

  • 첫 요청이 401을 받고 refresh 시작
  • refreshPromise 생성 → 실제 refresh API 호출
  • 이후 요청들은 refreshPromise를 await
  • refresh 완료 → 쿠키에 최신 토큰 저장
  • 대기 중이던 모든 요청이 최신 토큰으로 재시도

다음 사이클에서도 동일한 패턴 반복

마치며

이번 이슈는 단순히 "401 에러를 어떻게 처리할까"의 문제가 아니었다. 동시에 발생한 요청들 사이에서 인증 상태를 어떻게 일관되게 유지할 것인가에 대한 문제였다.

작은 구조 개선이었지만, 결과적으로 서비스의 안정성과 사용자 경험 모두에 의미 있는 변화를 만들었다. 비동기 에러 처리도 결국은 흐름과 순서를 설계하는 일이라는 걸 다시 한 번 느낀 사례였다.

또한 Single-Flight 패턴을 학습하면서 뮤텍스와 세마포어를 떠올리게 되었는데, 둘 다 동시 실행을 제어한다는 점에서는 유사하지만, Single-Flight는 단순히 임계 구역을 보호하는 데 그치지 않고 한 번 수행된 작업의 결과를 여러 요청이 공유한다는 점에서 차이가 있다고 느꼈다.

개념 목적 특징
Mutex 동시에 못 들어오게 막음 나머지는 대기
Semaphore 동시 개수 제한 N개 허용
Single-Flight 중복 실행 제거 결과 공유